package org.erikaredmark.monkeyshines.resource; import java.awt.Graphics2D; import java.awt.image.BufferedImage; import java.io.BufferedInputStream; import java.io.IOException; import java.io.InputStream; import java.nio.file.Path; import java.util.Collections; import java.util.HashSet; import java.util.IdentityHashMap; import java.util.Map; import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.zip.ZipFile; import java.util.zip.ZipEntry; import javax.imageio.ImageIO; import javax.sound.sampled.Clip; import javax.sound.sampled.LineUnavailableException; import javax.sound.sampled.UnsupportedAudioFileException; import org.erikaredmark.monkeyshines.GameConstants; import org.erikaredmark.monkeyshines.GameSoundEffect; import org.erikaredmark.monkeyshines.background.Background; import org.erikaredmark.monkeyshines.background.FullBackground; import org.erikaredmark.monkeyshines.global.SoundSettings; import org.erikaredmark.monkeyshines.global.SoundUtils; import org.erikaredmark.monkeyshines.graphics.exception.ResourcePackException; import org.erikaredmark.monkeyshines.graphics.exception.ResourcePackException.Type; import org.erikaredmark.monkeyshines.tiles.CommonTile.StatelessTileType; import com.google.common.base.Optional; /** * * Unlike {@code World}, this contains all the graphics for a world, and only that. There is no level information. All worlds * must have some resource set otherwise they will not draw properly, and the resource should be sane to that world. By 'sane', * it means that: * <ol> * <li>For each different type of tile, there is a tile graphic. So if the maximum solid tile id for a world is 48, there better * be at least 48 rectangluar slots available in the graphic.</li> * <li>For each different type of Sprite, there is a sprite graphic sheet. Same rules of 'enough sprite graphic sheets' in terms * of sprite id apply here</li> * </ol> * <p/> * Instances of this class are publically immutable until disposed. Once constructed with all available graphics resources, this can not change * until {@code dispose() } is called. This is mainly designed to release sound resources and should be thought of as the 'destructor' of this object. * Only call when about to otherwise remove references to an instance of this class. * * @author Erika Redmark * */ public final class WorldResource { private static final String CLASS_NAME = "org.erikaredmark.monkeyshines.resource.WorldResource"; private static final Logger LOGGER = Logger.getLogger(CLASS_NAME); /* ---------------------------- TILES ----------------------------- */ private final BufferedImage solidTiles; private final BufferedImage thruTiles; private final BufferedImage sceneTiles; /* --------------------------- HAZARDS ---------------------------- */ private final BufferedImage hazardTiles; /* ----------------------- CONVERYER BELTS ------------------------ */ private final BufferedImage conveyerTiles; // Special: lazily initialised (since the real game doesn't ask for // it) when editor asks for selecting conveyer belts. private BufferedImage editorConveyerTiles; /* -------------------------- COLLAPSING -------------------------- */ private final BufferedImage collapsingTiles; // Another lazily initialised sprite sheet specific to the editor. private BufferedImage editorCollapsingTiles; /* -------------------------- BACKGROUND -------------------------- */ private final Background backgrounds[]; private final Background patterns[]; /* --------------------------- SPRITES ---------------------------- */ private final BufferedImage sprites[]; /* --------------------------- GOODIES ---------------------------- */ private final BufferedImage goodieSheet; private final BufferedImage yumSheet; /* -------------------------- Explosions -------------------------- */ private final BufferedImage explosionSheet; /* ------------------------- UI Elements -------------------------- */ // Shown on the top the main game screen; gives score, bonus, lives, current powerup, and current world. private final BufferedImage banner; // Bitmap numbers for drawing the score on the banner. private final BufferedImage scoreNumbers; // Bitmap numbers for drawing the bonus score remaining on the banner private final BufferedImage bonusNumbers; private final BufferedImage splashScreen; private final BufferedImage energyBar; /* --------------------------- SOUNDS ----------------------------- */ // Whilst sounds are stored here, they should only be played via the // SoundManager. null sounds are possible; in that case, that means // that there is no sound available for a particular event. private final Map<GameSoundEffect, Optional<Clip>> sounds; private final Set<GameSoundEffect> holdSounds = new HashSet<>(); // Package-private: Only intended for SoundManager final Optional<Clip> backgroundMusic; // Generated automatically in constructor private final SoundManager soundManager; private int conveyerCount; private int collapsingCount; /* -- Internal -- */ private static final Pattern INDEX_PATTERN = Pattern.compile("^.*?([0-9]+)\\.png$"); /** Static factories call this with proper defensive copying. No defensive copying is done in constructor */ private boolean isDisposed; private WorldResource(final BufferedImage solidTiles, final BufferedImage thruTiles, final BufferedImage sceneTiles, final BufferedImage hazardTiles, final BufferedImage conveyerTiles, final BufferedImage collapsingTiles, final Background[] backgrounds, final Background[] patterns, final BufferedImage[] sprites, final BufferedImage goodieSheet, final BufferedImage yumSheet, final BufferedImage banner, final BufferedImage scoreNumbers, final BufferedImage bonusNumbers, final BufferedImage explosionSheet, final BufferedImage splashScreen, final BufferedImage energy, final Map<GameSoundEffect, Optional<Clip>> sounds, final Optional<Clip> backgroundMusic) { this.solidTiles = solidTiles; this.thruTiles = thruTiles; this.sceneTiles = sceneTiles; this.hazardTiles = hazardTiles; this.collapsingTiles = collapsingTiles; this.backgrounds = backgrounds; this.patterns = patterns; this.sprites = sprites; this.conveyerTiles = conveyerTiles; this.goodieSheet = goodieSheet; this.yumSheet = yumSheet; this.sounds = sounds; // May be null this.backgroundMusic = backgroundMusic; this.banner = banner; this.scoreNumbers = scoreNumbers; this.bonusNumbers = bonusNumbers; this.explosionSheet = explosionSheet; this.splashScreen = splashScreen; // Generated data // this pointer escapes, but no one gets a reference to the manager until construction is over // and the manager constructor itself calls no methods on this class. soundManager = SoundSettings.setUpSoundManager(this); // unregistered in dispose method SoundSettings.registerSoundManager(soundManager); // Height of conveyer sheet can calculate total conveyers in world // Remember, a single set is both clockwise and anti-clockwise (hence times 2) // Empty worlds, and perhaps other worlds, may have no conveyer belts conveyerCount = conveyerTiles != null ? conveyerTiles.getHeight() / (GameConstants.TILE_SIZE_Y * 2) : 0; // Simpler than conveyer; height / size of tiles easily gives collapsable tile count collapsingCount = collapsingTiles != null ? collapsingTiles.getHeight() / GameConstants.TILE_SIZE_Y : 0; // Energy bar is special. We explode the 8x11 image into a full 150x11 image. this.energyBar = new BufferedImage(150, 11, energy.getType() ); Graphics2D g2dEnergyBar = this.energyBar.createGraphics(); try { g2dEnergyBar.drawImage(energy, 0, 0, 2, 11, 0, 0, 2, 11, null); // 2 pixel on each side from the 150 total gives 146 pixels to fill (41 iterations) // math is kept in to make calculations a bit more obvious for (int i = 0; i < 146; i += 4) { int startX = 2 + i; g2dEnergyBar.drawImage(energy, startX, 0, startX + 4, 11, 2, 0, 6, 11, null); } g2dEnergyBar.drawImage(energy, 148, 0, 150, 11, 6, 0, 8, 11, null); } finally { g2dEnergyBar.dispose(); } } /** * * Initialises this resource object from a resource pack. A resource pack is a zipped folder containing the following * contents (note that brackets @{code [ ]} indicate a fill in, not a character literal): * <ol> * <li>solids.png</li> * <li>thrus.png</li> * <li>scenes.png</li> * <li>hazards.png</li> * <li>conveyers.png</li> * <li>collapsing.png</li> * <li>bonusNumbers.png</li> * <li>scoreNumbers.png</li> * <li>background[#].png (from 0 to some value with no breaks)</li> * <li>pattern[#].png (from 0 to some value with no breaks)</li> * <li>sprite[#].png (from 0 to some value with no breaks)</li> * <li>goodies.png</li> * <li>yums.png</li> * <li>explosion.png</li> * <li>uibanner.png</li> * </ol> * TODO add sounds and music when names are finalised * If there is any issue with the pack (missing resource, background2.png with no background1.png, for examples) this * method will throw an exception. Otherwise, this will load all the graphics into memory and have them ready to * be applied to a world. * * @param packFile * a .zip file containing the resource pack * * @param intent * an 'intent' enumeration that determines how the resource pack will be used, such as via editor or * the game. Information from a resource pack is only loaded in memory if the given subsystem specified * by the intent will be using it. * * @return * a resource object with the pack loaded into memory * * @throws ResourcePackException * if the resource pack is corrupted * * @throws IllegalArgumentException * if the given path is not even a .zip file * */ public static WorldResource fromPack(final Path packFile, UseIntent intent) throws ResourcePackException { // TODO replace with reading magic number http://www.coderanch.com/t/381509/java/java/check-file-zip-file-java // Declare non final versions of instance data. However, we enforce only replacing null in other ways in the below code. // Once they are added to the world resource object they will become final. BufferedImage solidTiles = null; BufferedImage thruTiles = null; BufferedImage sceneTiles = null; BufferedImage hazardTiles = null; BufferedImage conveyerTiles = null; BufferedImage collapsingTiles = null; // Max index will be used to tell the validator how far to count to in the array list to confirm // contiguous entries. (as in, if 'background4' exists, then 'background0, background1, etc' MUST exist. // Initially -1. That means no elements. The max index is NOT size, so 0 still would mean at least 1, which // we don't know yet. // TODO we assume no more than 256 backgrounds, 256 patterns, and 256 sprites. This is simply because we might // hit a later indexed item out of order and List implementations don't allow adding at specific future indexes. BufferedImage[] backgrounds = new BufferedImage[256]; int maxBackgroundIndex = -1; BufferedImage[] patterns = new BufferedImage[256]; int maxPatternIndex = -1; BufferedImage[] sprites = new BufferedImage[256]; int maxSpriteIndex = -1; BufferedImage goodieSheet = null; BufferedImage yumSheet = null; BufferedImage bannerSheet = null; BufferedImage scoreNumbersSheet = null; BufferedImage bonusNumbersSheet = null; BufferedImage explosionSheet = null; BufferedImage splashScreen = null; BufferedImage energyBar = null; // Sound clips // Unlike graphics, some sounds may not exist, and that is okay. The game just won't play // any sound when requested. // Optional is required to reduce ambigiuity in map Map<GameSoundEffect, Optional<Clip>> gameSounds = new IdentityHashMap<>(); // It is okay for this to be null. No music simply means none will be played Optional<Clip> backgroundMusic = null; try (ZipFile zipFile = new ZipFile(packFile.toFile() ) ) { // for (ZipEntry e : file.entries) // Java Specialists newsletter: more efficient way to do this when I have time // TODO http://www.javaspecialists.eu/archive/Issue107.html for (ZipEntry entry : Collections.list(zipFile.entries() ) ) { if (entry.isDirectory() ) continue; // contents of directories will be iterated over anyway. final String entryName = getFilename(entry); // FIRST: Handle hardcoded names that do not have continuations (numerical values from 0 to some number) switch (entryName ) { /* --------------------------------- Graphics Other Than Sprites --------------------------------- */ case "solids.png": if (solidTiles != null) throw new ResourcePackException(Type.MULTIPLE_DEFINITION, "solids.png"); solidTiles = ImageIO.read(zipFile.getInputStream(entry) ); break; case "thrus.png": if (thruTiles != null) throw new ResourcePackException(Type.MULTIPLE_DEFINITION, "thrus.png"); thruTiles = ImageIO.read(zipFile.getInputStream(entry) ); break; case "scenes.png": if (sceneTiles != null) throw new ResourcePackException(Type.MULTIPLE_DEFINITION, "scenes.png"); sceneTiles = ImageIO.read(zipFile.getInputStream(entry) ); break; case "conveyers.png": if (conveyerTiles != null) throw new ResourcePackException(Type.MULTIPLE_DEFINITION, "conveyer.png"); conveyerTiles = ImageIO.read(zipFile.getInputStream(entry) ); break; case "collapsing.png": if (collapsingTiles != null) throw new ResourcePackException(Type.MULTIPLE_DEFINITION, "collapsing.png"); collapsingTiles = ImageIO.read(zipFile.getInputStream(entry) ); break; case "goodies.png": if (goodieSheet != null) throw new ResourcePackException(Type.MULTIPLE_DEFINITION, "goodies.png"); goodieSheet = ImageIO.read(zipFile.getInputStream(entry) ); break; case "yums.png": if (yumSheet != null) throw new ResourcePackException(Type.MULTIPLE_DEFINITION, "yums.png"); yumSheet = ImageIO.read(zipFile.getInputStream(entry) ); break; case "hazards.png": if (hazardTiles != null) throw new ResourcePackException(Type.MULTIPLE_DEFINITION, "hazards.png"); hazardTiles = ImageIO.read(zipFile.getInputStream(entry) ); break; case "uibanner.png": if (bannerSheet != null) throw new ResourcePackException(Type.MULTIPLE_DEFINITION, "uibanner.png"); bannerSheet = ImageIO.read(zipFile.getInputStream(entry) ); break; case "energy.png": if (energyBar != null) throw new ResourcePackException(Type.MULTIPLE_DEFINITION, "energy.png"); energyBar = ImageIO.read(zipFile.getInputStream(entry) ); break; case "bonusNumbers.png": if (bonusNumbersSheet != null) throw new ResourcePackException(Type.MULTIPLE_DEFINITION, "bonusNumbers.png"); bonusNumbersSheet = ImageIO.read(zipFile.getInputStream(entry) ); break; case "scoreNumbers.png": if (scoreNumbersSheet != null) throw new ResourcePackException(Type.MULTIPLE_DEFINITION, "scoreNumbers.png"); scoreNumbersSheet = ImageIO.read(zipFile.getInputStream(entry) ); break; case "explosion.png": if (explosionSheet != null) throw new ResourcePackException(Type.MULTIPLE_DEFINITION, "explosion.png"); explosionSheet = ImageIO.read(zipFile.getInputStream(entry) ); break; case "music.ogg": if (intent != UseIntent.GAME) continue; if (backgroundMusic != null) throw new ResourcePackException(Type.MULTIPLE_DEFINITION, "music.ogg"); backgroundMusic = loadSoundClip(zipFile, entry); break; case "splash.png": if (intent != UseIntent.GAME) continue; if (splashScreen != null) throw new ResourcePackException(Type.MULTIPLE_DEFINITION, "splash.png"); splashScreen = ImageIO.read(zipFile.getInputStream(entry) ); break; // All other types are handled in default, as many different names may belong to one 'class' of things. default: // TODO repeated code here: consider refactoring? /* -------------------- Backgrounds -------------------- */ if (entryName.matches("^background[0-9]+\\.png$") ) { int index = indexFromName(entryName); // Index out of bounds exception if we check and the array isn't big enough. If index is greater than size, then // there was no previous anyway. If it isn't, make sure it is null if (backgrounds.length > index) { if (backgrounds[index] != null) throw new ResourcePackException(Type.MULTIPLE_DEFINITION, entry.getName() ); } if (index > maxBackgroundIndex) maxBackgroundIndex = index; BufferedImage tempBackground = ImageIO.read(zipFile.getInputStream(entry) ); backgrounds[index] = tempBackground; /* ---------------------- Sprites ---------------------- */ } else if (entryName.matches("^sprite[0-9]+\\.png$") ) { int index = indexFromName(entryName); if (sprites.length > index) { if (sprites[index] != null) throw new ResourcePackException(Type.MULTIPLE_DEFINITION, entry.getName() ); } if (index > maxSpriteIndex) maxSpriteIndex = index; BufferedImage tempSprite = ImageIO.read(zipFile.getInputStream(entry) ); sprites[index] = tempSprite; } else if (entryName.matches("^pattern[0-9]+\\.png$") ) { int index = indexFromName(entryName); if (patterns.length > index) { if (patterns[index] != null) throw new ResourcePackException(Type.MULTIPLE_DEFINITION, entry.getName() ); } if (index > maxPatternIndex) maxPatternIndex = index; BufferedImage tempPattern = ImageIO.read(zipFile.getInputStream(entry) ); patterns[index] = tempPattern; /* ---------------------- Sounds ----------------------- */ // Due to the nature of graphics amounts being unknown, // but types of sounds being finite, any name of any file // not matching any other pattern IS a sound. // TODO may not be best plan. May cause issues if we wish to allow // additional 'stuff' as part of the resource pack, like readmes and whatnot. } else { GameSoundEffect sound = GameSoundEffect.filenameToEnum(entryName); if (sound == null) { System.out.println("Information: " + entry.getName() + " not a valid resource in resource pack. Skipping."); continue; } else { if (gameSounds.containsKey(sound) ) throw new ResourcePackException(Type.MULTIPLE_DEFINITION, entry.getName() ); gameSounds.put(sound, loadSoundClip(zipFile, entry) ); } } } } } catch (IOException e) { throw new ResourcePackException(e); } // FINAL CHECKS // 0) Splash Screen was present // 1) Nothing is null // 2) Array lists go from 0 to some value with no skips if (intent == UseIntent.GAME) { checkResourceNotNull(splashScreen, "splash.png"); } checkResourceNotNull(solidTiles, "solids.png"); checkResourceNotNull(thruTiles, "thrus.png"); checkResourceNotNull(sceneTiles, "scenes.png"); checkResourceNotNull(goodieSheet, "goodies.png"); checkResourceNotNull(yumSheet, "yums.png"); checkResourceNotNull(hazardTiles, "hazards.png"); checkResourceNotNull(conveyerTiles, "conveyers.png"); checkResourceNotNull(collapsingTiles, "collapsing.png"); checkResourceContiguous(backgrounds, maxBackgroundIndex, "background"); checkResourceContiguous(sprites, maxSpriteIndex, "sprite"); checkResourceContiguous(patterns, maxPatternIndex, "pattern"); checkResourceNotNull(explosionSheet, "explosion.png"); checkResourceNotNull(scoreNumbersSheet, "scoreNumbers.png"); checkResourceNotNull(bonusNumbersSheet, "bonusNumbers.png"); checkResourceNotNull(bannerSheet, "uibanner.png"); checkResourceNotNull(energyBar, "energy.png"); // Backgrounds and Patterns may be empty, but sprites must contain at least 1 if (sprites[0] == null) throw new ResourcePackException(Type.NO_DEFINITION, "There are no sprites for this world; must contain at least one"); // Sounds may be null // No null checks // We must convert backgrounds and patterns into proper background objects Background[] fullBackgrounds = new Background[maxBackgroundIndex + 1]; Background[] patternBackgrounds = new Background[maxPatternIndex + 1]; for (int i = 0; i <= maxBackgroundIndex ; i++) { fullBackgrounds[i] = FullBackground.of(backgrounds[i], i); } for (int i = 0; i <= maxPatternIndex ; i++) { patternBackgrounds[i] = FullBackground.fromPattern(patterns[i], i); } // We need to construct an array of sprites that has identical references save for being a lot smaller BufferedImage cutSprites[] = new BufferedImage[maxSpriteIndex + 1]; for (int i = 0; i <= maxSpriteIndex; i++) { cutSprites[i] = sprites[i]; } WorldResource worldRsrc = new WorldResource(solidTiles, thruTiles, sceneTiles, hazardTiles, conveyerTiles, collapsingTiles, fullBackgrounds, patternBackgrounds, cutSprites, goodieSheet, yumSheet, bannerSheet, scoreNumbersSheet, bonusNumbersSheet, explosionSheet, splashScreen, energyBar, gameSounds, backgroundMusic); return worldRsrc; } /** * * Resolves just the filename from an entry name. The entry name contains the path to the file relative * to the .zip, but in many cases we care only about the actual name, regardless of origin. * <p/> * This method will fail on directories (since they end with a slash) * * @param entryName * entry name from the zip entry * * @return * just the filename * * @throws IllegalArgumentException * if called on a directory entry * */ private static String getFilename(ZipEntry entry) { if (entry.isDirectory() ) throw new IllegalArgumentException("Cannot call method with directories"); final String entryName = entry.getName(); int slash = entryName.lastIndexOf("/"); if (slash == -1) return entryName; else return entryName.substring(slash + 1); } /** * * Treats the contents of the zip as an ogg encoded sound file and loads the Entire File into memory, returning a * {@code Clip} representing the sound. Only short sound effects are loaded completely; longer sounds like music * should be streamed. * <p/> * If the clip cannot be loaded, absent is returned. It is up to sound manager * systems to handle unwrapping Optionals. * * @param file * the zip file the zip entry comes from * * @param entry * the zip entry containing the sound * * @return * a clip of the sound. The entire sound will be stored in memory * * @throws ResourcePackException * if the sound clip could not be loaded * */ private static Optional<Clip> loadSoundClip(ZipFile file, ZipEntry entry) throws ResourcePackException { // Load the audio stream from the entry // Buffered stream to allow mark/reset try (InputStream bin = new BufferedInputStream(file.getInputStream(entry) ) ) { return Optional.of( SoundUtils.clipFromOggStream(bin, entry.getName() ) ); } catch (UnsupportedAudioFileException e) { LOGGER.log( Level.SEVERE, "Check that resources are of ogg format and that system is " + "able to read ogg format:" + e.getMessage(), e); } catch (IOException | LineUnavailableException e) { LOGGER.log( Level.SEVERE, "Unable to get line to sound system; cannot initialise clip: " + e.getMessage(), e); } catch (Exception e) { LOGGER.log( Level.SEVERE, "Unexpected exception initialising clip: " + e.getMessage(), e); } return Optional.absent(); } /** * * Regardless of the actual filename, extracts the number that appears in it. This is designed for filenames of the * type "somename135.png, where the number appears before the .png extension. This is for numbered images that have * an unknown number of graphics, such as sprites for a world. * * @param name * @return */ private static int indexFromName(final String name) throws ResourcePackException { Matcher matcher = INDEX_PATTERN.matcher(name); boolean match = matcher.matches(); if (match == false) throw new ResourcePackException(Type.NO_INDEX_NUMBER, name + " should contain an index number before .png"); String integer = matcher.group(1); return Integer.parseInt(integer); } private static void checkResourceNotNull(BufferedImage img, String name) throws ResourcePackException { if (img == null) throw new ResourcePackException(Type.NO_DEFINITION, name); } /** Ensures that an array counts from 0 to max index, with no skips in between (skips mean a null entry) */ private static void checkResourceContiguous(final Object[] items, int maxIndex, String name) throws ResourcePackException { for (int i = 0; i <= maxIndex; i++) { if (items[i] == null) throw new ResourcePackException(Type.NON_CONTIGUOUS, name + i + ".png"); } } /** * * Returns the graphics sheet for the tiles that exist for the given tile type. * * @param type * the type of the tile * * @return * a reference to the sprite sheet for the tiles * */ public BufferedImage getStatelessTileTypeSheet(final StatelessTileType type) { switch (type) { case SOLID: return solidTiles; case THRU : return thruTiles; case SCENE: return sceneTiles; case NONE: throw new IllegalArgumentException("No tilesheet for NONE tiles"); default: throw new IllegalArgumentException("Unknown tile type " + type); } } /** * * Returns the sprite sheet for the given id * * @param id * the id of the sprite sheet * * @return * a sprite sheet * * @throws ArrayIndexOutOfBoundsException * if the given id is more than the number of sprites this resource contains * */ public BufferedImage getSpritesheetFor(int id) { return sprites[id]; } /** * * The sprite sheet for goodies, such as powerups, fruit, and keys. * * @return * goodie sheet * */ public BufferedImage getGoodieSheet() { return this.goodieSheet; } /** * * The animation sheet for a yum. This is the YUM letters that appear over the tile that contained a goodie right * after bonzo grabs it. * * @return * the yum sheet * */ public BufferedImage getYumSheet() { return this.yumSheet; } /** * * Returns a non-repeating background exactly the proper dimensions of a single screen, from the given id. * * @param id * the id of the background * * @return * a background for the screen to use * * @throws ArrayIndexOutOfBoundsException * if the given id is more than the number of backgrounds this resource contains * */ public Background getBackground(int id) { return backgrounds[id]; } /** * * Returns the number of non-repeating backgrounds in this world * * @return * */ public int getBackgroundCount() { return backgrounds.length; } /** * * Returns the background pattern given by the current id * * @param id * id of pattern * * @return * a background created from tiling the pattern across 640x400 pixels of space * * @throws ArrayIndexOutOfBoundsException * if the given id is more than the number of patterns this resource contains * */ public Background getPattern(int id) { return patterns[id]; } public int getPatternCount() { return patterns.length; } /** * * Returns the number of unique sprite graphics present in this world resource. Note that for indexing purposes in the * sprite array, the last index of the sprite graphic is one less than this value. * * @return * number of unique sprites for world * */ public int getSpritesCount() { return sprites.length; } /** * * Returns the hazard sprite sheet for this world. There are two rows for an animating hazard, and each column is * the id of the hazard that would be using that graphic. * * @return * hazard sprite sheet * */ public BufferedImage getHazardSheet() { return hazardTiles; } /** * * Returns the number of uniquely graphical hazards this world can display. * * @return * number of unique hazards * */ public int getHazardCount() { return hazardTiles.getWidth() / GameConstants.TILE_SIZE_X; } /** * * Returns the explosion sprite sheet. Explosions are the size of a tile and have 8 frames * of animation. * * @return * explosion sprite sheet * */ public BufferedImage getExplosionSheet() { return explosionSheet; } /** * * Returns the sprite sheet used for drawing score numbers in the UI. Each number is 16 pixels by 30. * Getting a number to draw is easy; the number needed (0-9) multiplied by 16 gives the x starting * point, and all numbers are the same size and at the same y-level of 0 * * @return * score numbers sheet * */ public BufferedImage getScoreNumbersSheet() { return scoreNumbers; } /** * * Returns the sprite sheet used for drawing bonus numbers in the UI. Each number is 16 pixels by 30. * Getting a number to draw is easy; the number needed (0-9) multiplied by 16 gives the x starting * point, and all numbers are the same size and at the same y-level of 0 * * @return * bonus numbers sheet * */ public BufferedImage getBonusNumbersSheet() { return bonusNumbers; } /** * * Returns the banner that appears at the top of every game. This is the main UI where players will see * bonzos health, lives, score, bonus score, current powerup, and current world. It is the width of the game * screen by a {@code GameConstants.UI_HEIGHT) height. * * @return * main ui banner * */ public BufferedImage getBanner() { return banner; } /** * * Returns the sprite sheet for the conveyers. Each conveyer belt is stored as 2 rows of 5 sprites * each the size of a tile. The first row of five tiles is the first conveyer belt going clockwise. * The second row of five tiles is the first conveyer belt going anti-clockwise. This goes on and * on for as many conveyer belts exist. Because all conveyer belts come in pairs, the width will * always be {@code 5 * GameConstants.TILE_SIZE_X} and {@code 2 * GameConstants.TILE_SIZE_Y * <number of conveyers>} * * @return * conveyer belt sprite sheet * */ public BufferedImage getConveyerSheet() { return conveyerTiles; } /** * * Returns the full 150x11 energy bar, as generated from the 8x11 image. Clients should draw as much of * the bar as represents Bonzo's health. * * @return * the energy bar * */ public BufferedImage getEnergyBar() { return energyBar; } /** * * Designed for editor; returns the conveyer selection image that a user would use to select which * conveyer belt they want. The sprite sheet is generated to best match the dimensions for the given * amount of conveyer belts. Each 'frame' of the sheet contains the next id of conveyer clockwise, then * the same id anti-clockwise, and then repeats. * Mathmatically, the conveyer id and rotation can be deduced by determining which 'frame' the user * clicked on (basic division and modulus based on sheet size). The 'frame' index / 2 is the conveyer * id. If the 'frame' index is odd, it is an anti-clockwise belt. Otherwise, it is clockwise. * Each 'frame' is a TILE_SIZE_X by TILE_SIZE_Y check that represents a single tile, and they are ordered * from top left to bottom right. * The generated sprite sheet may not be completely filled with conveyers. Clients must check that the * click actually is a valid conveyer for the world. * * @return * sprite sheet specific for editor to show user to allow conveyer picking * */ public BufferedImage getEditorConveyerSheet() { // Lazy initialise; no need in creating sheet if the actual game is being played as it won't be used there if (editorConveyerTiles == null) { int width = conveyerTiles.getWidth() * 2; // 5 frames of animation * 2 gives 10 frames. 2 frames used per 'Type' meaning that // We need TILE_SIZE_Y units of height per 5 unique conveyer belts. int height = (1 + (conveyerCount / 5) ) * GameConstants.TILE_SIZE_Y; // Generate context for drawing BufferedImage sheet = new BufferedImage(width, height, conveyerTiles.getType() ); // Draw on sheet Graphics2D graphics = sheet.createGraphics(); //graphics.setColor(new Color(100, 100, 100, 100) ); //graphics.fillRect(0, 0, width, height); for (int i = 0; i < conveyerCount; i++) { // Draw the second frame, which has a little rotation, to give user the sense // of which direction the conveyer is going in. int drawFromX = GameConstants.TILE_SIZE_X; int drawFromY = GameConstants.TILE_SIZE_Y * i * 2; // We drop down a level per 10 conveyers int drawToX = ((i * 2) % 10) * GameConstants.TILE_SIZE_X; int drawToY = ((i * 2) / 10) * GameConstants.TILE_SIZE_Y; // We have the x, y for the Clockwise conveyer in both source and destination graphics.drawImage(conveyerTiles, drawToX, drawToY, drawToX + GameConstants.TILE_SIZE_X, drawToY + GameConstants.TILE_SIZE_Y, drawFromX, drawFromY, drawFromX + GameConstants.TILE_SIZE_X, drawFromY + GameConstants.TILE_SIZE_Y, null); // Do Anti-clockwise conveyer. drawFromY += GameConstants.TILE_SIZE_Y; drawToX += GameConstants.TILE_SIZE_X; graphics.drawImage(conveyerTiles, drawToX, drawToY, drawToX + GameConstants.TILE_SIZE_X, drawToY + GameConstants.TILE_SIZE_Y, drawFromX, drawFromY, drawFromX + GameConstants.TILE_SIZE_X, drawFromY + GameConstants.TILE_SIZE_Y, null); } editorConveyerTiles = sheet; graphics.dispose(); } return editorConveyerTiles; } /** * * Returns the sprite sheet for collapsing tiles, which have 10 frames in them. The final frame is typically * empty, but does not need to be. It represents the 'final state' where bonzo can fall through the floor. * The other nine are the process of the tile collapsing. * <p/> * Each row has 10 frames. The first frame of each row is the 'full version' of that specific collapsing tile. * <p/> * Each row indicates a new type of collapsing tile. * * @return * */ public BufferedImage getCollapsingSheet() { return collapsingTiles; } /** * * Returns a sprite sheet optimised for the editor, allowing the user to choose a unique type of collapsing * tile. From each set of 10 frames that make up one collapsing tile, the first frame will be used as the * 'exmplar' for that collapsing tile set, and displayed such that the user may easily choose which unique * collapsable tile they want. * * @return * lazily initialised collapsable tile sheet for the editor. * */ public BufferedImage getEditorCollapsingSheet() { // Lazy initialise; no need in creating sheet if the actual game is being played as it won't be used there if (editorCollapsingTiles == null) { int width = collapsingTiles.getWidth() * 2; // 10 frames per collapsing. The editor sprite sheet will show 10 unique collapsing tiles // per row. int height = (1 + (collapsingCount / 10) ) * GameConstants.TILE_SIZE_Y; BufferedImage sheet = new BufferedImage(width, height, collapsingTiles.getType() ); Graphics2D graphics = sheet.createGraphics(); for (int i = 0; i < collapsingCount; i++) { // Draw only the first frame of the collapsing tile. int drawFromX = 0; int drawFromY = GameConstants.TILE_SIZE_Y * i; // We drop down a level per 10 collapsing tiles int drawToX = (i % 10) * GameConstants.TILE_SIZE_X; int drawToY = (i / 10) * GameConstants.TILE_SIZE_Y; graphics.drawImage(collapsingTiles, drawToX, drawToY, drawToX + GameConstants.TILE_SIZE_X, drawToY + GameConstants.TILE_SIZE_Y, drawFromX, drawFromY, drawFromX + GameConstants.TILE_SIZE_X, drawFromY + GameConstants.TILE_SIZE_Y, null); } editorCollapsingTiles = sheet; graphics.dispose(); } return editorCollapsingTiles; } /** * * Returns the number of unique, collapsible tiles in this resource. * * @return * */ public int getCollapsingCount() { return collapsingCount; } /** * * Returns the clip for the given sound effect, or {@code null} if the sound effect has no clip. Incomplete * resource packs may not contain all sounds. * * @param effect * the effect to get the sound clip for * * @return * the clip itself * */ Optional<Clip> getSoundFor(GameSoundEffect effect) { return sounds.get(effect); } public SoundManager getSoundManager() { return this.soundManager; } /** * * The 'destructor' of this object. Only call when about to otherwise remove a reference to the given * instance. Destroys all sound resources, and anything else claimed by this object that may not * be released under normal gc. * */ public void dispose() { for (GameSoundEffect effect : sounds.keySet() ) { Optional<Clip> c = sounds.get(effect); if (c.isPresent() ) { if (!(isSoundHeld(effect) ) ) { c.get().close(); } } } SoundSettings.unregisterSoundManager(soundManager); // Intended for anything that requires late disposal. isDisposed = true; } /** * * Prevents the given sound effect from being disposed on the dispose call. This is intended for fine-tuned * resource holding in case a single effect is required later even if the rest of the world is disposed. * <p/> * It is an error to call this whilst a sound is already held * * @param effect * the effect to NOT dispose * * @throws IllegalStateException * if a hold is already on the sound * */ public void holdSound(GameSoundEffect effect) { if (!(holdSounds.add(effect) ) ) { throw new IllegalArgumentException("Sound effect " + effect + " already held in previous request"); } } /** * * Releases the resource, allowing it to be disposed. If this object was already disposed, the resource is closed * as soon as this method returns. Otherwise, the resource becomes eligble to be destroyed on the next call to dispose. * * @param effect * the effect to release * * @throws IllegalStateException * if the resource is not already held * */ public void releaseSound(GameSoundEffect effect) { if (!(holdSounds.remove(effect) ) ) { throw new IllegalArgumentException("Sound effect " + effect + " was not previously held"); } // Are we already disposed? Clean it now. if (isDisposed) { Optional<Clip> c = sounds.get(effect); c.get().close(); } } /** * * Determines if a resource is held. Held resources may not be destroyed until released. * * @param effect * */ public boolean isSoundHeld(GameSoundEffect effect) { return holdSounds.contains(effect); } /** * * Returns the number of UNIQUE conveyer belts in this resource. Each 'count' includes * both the clockwise and anti-clockwise verions of a {@code Conveyer} object. * * @return * number of unique conveyer belt sets * */ public int getConveyerCount() { return conveyerCount; } /** * * Returns a 640x480 image of the splash screen. If this world resource was constructed with a {@code UseIntent} * for the editor, this method will return {@code null} * * @return * splash screen or {@code null} if this object was created for editor * */ public BufferedImage getSplashScreen() { return splashScreen; } /** * * Chops the given image into an array of images, indexed from top left to bottom right. The width/height supplied * must be divisible into the size of the passed image. * <p/> * This is NOT intended for gameplay, which should just draw from the sprite sheet when needed. This is intended for * when the editor needs to show all the possible image selections and each one needs to be mapped to a separate button/ * icon. * <p/> * Because of the way the algorithm works, the index in the array has a 1:1 mapping to the id of a tile when used to * chop up a basic tilesheet. * <br/>For hazards, the 1:1 mapping only applies to the first half * <br/>Conveyers, there are 10 images between 'ids' but switches from clockwise to Anti-clockwise every 5. * <br/>Collapsibles, there are 10 images between ids. * * @param width * width of each chop. Must be a divisor of the width of the entire image * * @param height * height of each chop. Must be a divisor of the height of the entire image * * @param sprites * the image containing the sprites * * @return * an array of buffered images of all the chops, indexed from 0 (top-left) to n (bottom-right) * */ public static BufferedImage[] chop(int width, int height, BufferedImage sprites) { assert sprites.getWidth() % width == 0; assert sprites.getHeight() % height == 0; BufferedImage[] chops = new BufferedImage[(sprites.getWidth() / width) * (sprites.getHeight() / height)]; { int index = 0; for (int j = 0; j < sprites.getHeight(); j += height) { for (int i = 0; i < sprites.getWidth(); i += width) { chops[index] = new BufferedImage(width, height, sprites.getType() ); Graphics2D g2d = chops[index].createGraphics(); try { g2d.drawImage(sprites, 0, 0, width, height, i, j, i + width, j + height, null); } finally { g2d.dispose(); } ++index; } } } return chops; } /** * * Indicates how the client wishes to use the resources. This is mainly a division between editor and game. * The purpose is to not load any resources that won't be used in a certain context. For example, no sounds or * music are loaded when the intent is for the level editor, since the level editor has no need of them. * * @author Erika Redmark * */ public enum UseIntent { GAME, EDITOR; } }